Skip to content

DHMemberPortal: make signup scaffolding writes best-effort (#283 bandaid)#284

Merged
tachoknight merged 6 commits into
devfrom
fix/signup-best-effort-scaffolding
May 27, 2026
Merged

DHMemberPortal: make signup scaffolding writes best-effort (#283 bandaid)#284
tachoknight merged 6 commits into
devfrom
fix/signup-best-effort-scaffolding

Conversation

@rubin110
Copy link
Copy Markdown
Contributor

@rubin110 rubin110 commented May 21, 2026

Summary

Bandaid for #283 plus four bundled fixes for signup-flow issues discovered during follow-up ad-hoc testing (see .claude/notes/2026-05-20-signup-flow-adhoc-findings.md).

Original bandaid (#283)

/signup/submit was non-atomic — 8 sequential HTTP calls inside one try/except — so any transient failure after add_member succeeded would abort the redirect to /signup/payment and strand the user with a half-created row.

  • Critical path (get_access_token, get_member_id, add_member) still bounces the user to /signup on failure.
  • Once add_member returns a member_id, the seven secondary JSONB writes run in a for-loop with per-step try/except. Each step logs success at INFO and failure at ERROR; the loop never throws.
  • User always reaches /signup/payment once the row exists.
  • Bumped seven secondary helpers in dhservices.py from timeout=10timeout=20.

Bundled fixes (signup-flow hardening, all caused-or-worsened by the bandaid)

Commit Finding Fix
a64fcce 10 — dashboard crashes when status.membership_status is None ((dict or {}).get('key') or '').lower() pattern across 5 portal/ST2DH sites
67c3195 5 + 14GET /signup did session.clear(), logging out any logged-in user and silently eating the "already exists" flash on retry Narrow the reset to signup-only session keys
657e93d 14 follow-up Render get_flashed_messages on signup_email.html so the flash actually surfaces
2fdbcd9 7 (username) + 9signup_submit accepted duplicate active_directory_username, including the concurrent race variant Server-side is_username_taken check before add_member; flash + redirect on collision
72f8abe 8 — concurrent same-email POSTs collapse into one row with last-write-wins, silently overwriting the loser's data DHService add_update_identity wraps the email-lookup + INSERT in a transactional advisory lock keyed on the lowercased email

Out of scope (separate follow-ups)

Filed as #292 (enumeration + rate-limit), #293 (server-side validation), #294 (/signup/payment arbitrary email), #295 (XSS render audit), #296 (logged-in user can create orphan members). None are bandaid-related.

Tests completed

All run live on dev with the rebuilt dh_member_portal + dh_service + dh_st2dh containers.

Bandaid core

  • Happy path signup walks email → form → /signup/payment; Stripe pricing-table iframe renders both tiers
  • DB row populated across all 8 JSONB columns after a successful signup
  • Per-step log lines verified: Created new member with ID: N + 7× Signup member N: <label> initialized
  • Inline-raise fault injection: forms + notes forced to fail mid-loop — user still reaches /signup/payment, those two columns are null, other 5 populated, ERROR log lines surfaced
  • Per-step fault injection across all 7 scaffolding steps individually (connections / status / forms / notes / access / authorizations / extras): each one fails cleanly, user always reaches /signup/payment, exactly the one targeted column is null and the other six are populated
  • CSRF protection: missing or bogus token → 302 to /signup, no row created
  • Existing-email path still returns "A member with this email already exists" flash

Finding 10 — membership_status None guard (a64fcce)

  • Manually-inserted member with status = NULL: dev-login + /dashboard renders without AttributeError: 'NoneType' object has no attribute 'lower'
  • Bandaid-produced status-null member (from per-step fault test): dev-login + walks all 5 dashboard pages (/dashboard, /dashboard/profile, /dashboard/keys, /dashboard/auths, /dashboard/storage, /dashboard/floof), every page HTTP 200, zero AttributeError in logs
  • Regression sweep across all 4 legitimate statuses (active/pending/suspended/banned seed members): every dashboard page returns HTTP 200, banned-gate correctly redirects banned member's /dashboard* URLs to /dashboard/locked, zero AttributeError in logs
  • Unit-tested the ((dict or {}).get(key) or '').lower() expression against all 4 input shapes (status=None, status[ms]=None, missing key, normal value)
  • Patch sites covered: 4 in code/DHMemberPortal/app.py + 1 in code/external/ST2DH/app.py

Finding 5 + 14 — narrow session reset + flash rendering (67c3195, 657e93d)

  • Dev-login as a seed member → visit /signup/dashboard still HTTP 200 (was 302 → /)
  • Logged-in user GET /signup no longer logs them out (Finding 5 confirmed end-to-end)
  • Duplicate-email POST flow: redirect to /signup → flash "A member with this email already exists" now renders on signup_email.html
  • Logged-in user POSTs /signup/submit with their OWN email → HTTP 302 → /signup (correctly routed through the "already exists" path; zero duplicate rows created)

Finding 7-username + 9 — server-side username uniqueness (2fdbcd9)

  • username=alovelace (exact case match against seed): rejected with "That username is already taken" flash, zero DB rows created
  • username=ALOVELACE (case-insensitive match): rejected the same way, zero rows
  • Confirms Determine RFID Entry Retention Policy #9 (concurrent same-username different-email) is also closed: both racing requests now hit the username check before add_member

Finding 8 — advisory lock on same-email INSERT (72f8abe)

  • Two parallel same-email POSTs: exactly one member row in DB; advisory lock serializes them so it's no longer possible to get two rows or to silently overwrite one side's identity
  • Stress: 15 concurrent same-email POSTs → exactly one member row created; 1 request reached /signup/payment, 14 routed to /signup "already exists". Zero deadlocks, zero lock-wait errors, zero exceptions in DHService or portal logs. Total completion: 8.4s (~550ms per serialized lock acquisition).

Test plan

Pre-merge — held until external prereqs exist

  • ST2DH webhook simulation — not feasible without real Stripe credentials. ST2DH calls stripe.Customer.retrieve(customer_id) against the live Stripe API to look up the customer's email; that fails immediately in a credential-less dev environment. Worth running once a dev Stripe test account is configured.

Post-merge / production

  • Watch for any new signup-related error log lines in prod once deployed
  • Verify status->>'stripe_product_id' lands as expected via the ST2DH webhook for a real new signup
  • Spot-check update_member_access from the dashboard Keys tab still saves within the 20s ceiling

Known trade-offs

  • update_member_access is also used by the dashboard Keys page profile save. The 20s timeout now applies there too. The worst case is a 20s synchronous Flask wait on a real DHService timeout (previously 10s); normal writes are sub-second.
  • The "Sign up successful! Please complete payment." flash fires even if all 7 scaffolding steps failed. By design: user is unblocked, admin tooling addresses the partial row later. Finding 10 ensures the partial row no longer crashes the dashboard.

🤖 Generated with Claude Code

Issue #283. Once add_member succeeds, the seven secondary JSONB writes
(connections/status/forms/notes/access/authorizations/extras) run in a
loop with per-step try/except so a transient failure no longer aborts
the redirect to /signup/payment.

Also bumps the 10s timeout to 20s on the seven secondary helpers in
dhservices.py for cheap insurance against slow DHService responses.

A failed scaffolding step leaves the corresponding JSONB column null;
admin tooling can address that later. The user still reaches Stripe,
which is the bandaid's only goal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
rubin110 and others added 5 commits May 22, 2026 00:10
dict.get('key', '') returns '' only when the key is missing — when the
key is present with a None value (which happens when v_member_info
materializes a NULL status column as {membership_status: null, ...}),
.get returns None and the following .lower() crashes with AttributeError.

The signup scaffolding bandaid in c77499b makes this state reachable in
production: when update_member_status fails, Stripe can later write its
stripe_* keys to status without anyone setting membership_status, leaving
that key None.

Swap to ((dict or {}).get('membership_status') or '').lower() at four
sites in DHMemberPortal (B2C authorized, _get_authenticated_member_info,
RFID gate in member_update_profile, dev_login_select) and the
equivalent in ST2DH update_membership (which was using raw [] indexing,
so also vulnerable to KeyError).

Verified live against dev DB row 51 (status column NULL): dashboard,
profile, keys, auths, storage pages all render. RFID gate refuses
update with status='' instead of 500. Pending/active/suspended/banned
flows still behave correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The unconditional session.clear() in signup_start logs out any signed-in
user who visits /signup (a GET, so trivially weaponizable as a logout
link via attacker-crafted URL) and wipes the flash queue, swallowing the
"A member with this email already exists" message when signup_submit
redirects back to signup_start. Replace with a narrow
session.pop('signup_email', None) — the only key the signup flow writes.

Addresses Findings 5 and 14 from the 2026-05-20 signup-flow sweep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
signup_submit had no server-side check for duplicate usernames — the
/api/check-username AJAX call on the form was only advisory, so a direct
POST (or a normal submit after a races on the AJAX call) could create a
second member row with a colliding active_directory_username. Downstream
this collides in AD/B2C when the dispatcher tries to provision the
account.

Add a case-insensitive gate using the existing
dhservices.is_username_taken helper (which calls
is_username_available in DHService, LOWER() = LOWER() per #252).
Closes Findings 7-username and 9 from the 2026-05-20 signup-flow sweep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tity

Two simultaneous signup_submit POSTs for the same primary email used to
both call get_member_id_from_email (opens its own connection, sees no
match), then both INSERT in separate connections — producing two member
rows with the same primary email. Downstream scaffolding writes that
re-resolve by email then silently overwrote the loser's data (Finding 8
in the 2026-05-20 signup-flow sweep).

Move the email lookup inside the same transaction as the INSERT/UPDATE
and take a pg_advisory_xact_lock keyed on hashtext(LOWER(email)) before
the lookup. Concurrent same-email signups serialize on the lock; the
loser sees the winner's row and falls through to the UPDATE branch
instead of inserting a duplicate. Different-email signups don't
contend. No schema change; the standard caller-supplied member_id path
is untouched.

A real UNIQUE constraint on primary email is the right long-term fix
but requires a schema migration and per-row dedup work first; that's a
separate follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous Finding 5+14 commit (67c3195) scoped the session reset so
flash messages survive the signup_submit → signup_start redirect, but
signup_email.html didn't actually render flashes — so the preserved
"A member with this email already exists" message still wouldn't show
on the resubmit-after-back path. Add the same get_flashed_messages
block used by _dashboard_base.html so the flash actually surfaces.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@rubin110
Copy link
Copy Markdown
Contributor Author

@tachoknight Throwing this to you to give it a once over please.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants